diff options
| author | Bertrand Yuan <bert.yuan@outlook.com> | 2025-12-16 00:12:49 +0800 |
|---|---|---|
| committer | Bertrand Yuan <bert.yuan@outlook.com> | 2025-12-16 00:12:49 +0800 |
| commit | 02ae938c238c9d18448d17a8ec92c0edd8c17463 (patch) | |
| tree | dcd6a30505adb52522b20af2c0ac27f713403f10 /src/app/(main)/(home)/posts/[slug] | |
| parent | 48b07bc308a35734a6a7a305c8fdccbfa47de7d8 (diff) | |
feat(back-end): introduce payload
Payload is the next.js Headless CMS and App Framework,
I would like to pick it up and modify it as it is MIT licensed.
Many features in Payload is not applicable for our project.
So, I modify it so that it is light and clear.
Diffstat (limited to 'src/app/(main)/(home)/posts/[slug]')
| -rw-r--r-- | src/app/(main)/(home)/posts/[slug]/page.client.tsx | 57 | ||||
| -rw-r--r-- | src/app/(main)/(home)/posts/[slug]/page.tsx | 266 |
2 files changed, 323 insertions, 0 deletions
diff --git a/src/app/(main)/(home)/posts/[slug]/page.client.tsx b/src/app/(main)/(home)/posts/[slug]/page.client.tsx new file mode 100644 index 0000000..7a97f56 --- /dev/null +++ b/src/app/(main)/(home)/posts/[slug]/page.client.tsx @@ -0,0 +1,57 @@ +'use client'; +import { + UploadIcon as ShareIcon, + type UploadIconHandle as ShareIconHandle, +} from '@/components/icons/animated/upload'; +import { Icons } from '@/components/icons/icons'; +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; +import { Comments } from '@fuma-comment/react'; +import { redirect } from 'next/navigation'; +import { useRef } from 'react'; +import { toast } from 'sonner'; +import { useCopyToClipboard } from 'usehooks-ts'; + +export function Share({ url }: { url: string }): React.ReactElement { + const iconRef = useRef<ShareIconHandle>(null); + const [_, copyToClipboard] = useCopyToClipboard(); + + const onClick = async (): Promise<void> => { + await copyToClipboard(`${window.location.origin}${url}`); + toast.success('Copied to clipboard!', { + icon: <Icons.copied className='size-4' />, + description: 'The post link has been copied to your clipboard.', + }); + }; + + return ( + <Button + className={cn('group gap-2')} + variant={'secondary'} + onClick={onClick} + onMouseEnter={() => iconRef.current?.startAnimation?.()} + onMouseLeave={() => iconRef.current?.stopAnimation?.()} + > + <ShareIcon className='size-4 hover:bg-transparent' ref={iconRef} /> + Share Post + </Button> + ); +} + +export function PostComments({ + slug, + className, +}: { slug: string; className?: string }) { + return ( + <Comments + page={slug} + className={cn('w-full', className)} + auth={{ + type: 'api', + signIn: () => { + redirect('/login'); + }, + }} + /> + ); +} diff --git a/src/app/(main)/(home)/posts/[slug]/page.tsx b/src/app/(main)/(home)/posts/[slug]/page.tsx new file mode 100644 index 0000000..fa096b6 --- /dev/null +++ b/src/app/(main)/(home)/posts/[slug]/page.tsx @@ -0,0 +1,266 @@ +import { + PostComments, + Share, +} from '@/app/(main)/(home)/posts/[slug]/page.client'; +import { PostJsonLd } from '@/components/json-ld'; +import { RichText } from '@/components/rich-text'; +import { Section } from '@/components/section'; +import { TagCard } from '@/components/tags/tag-card'; +import { createMetadata } from '@/lib/metadata'; +import { metadataImage } from '@/lib/metadata-image'; +import { + getPostBySlug, + getAllPostSlugs, + type BlogPost, +} from '@/lib/payload-posts'; +import { type Page as MDXPage, getPost, getPosts } from '@/lib/source'; +import { cn } from '@/lib/utils'; +import { File, Files, Folder } from 'fumadocs-ui/components/files'; +import { InlineTOC } from 'fumadocs-ui/components/inline-toc'; +import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; +import defaultMdxComponents from 'fumadocs-ui/mdx'; +import type { Metadata } from 'next'; +import { notFound } from 'next/navigation'; +import Balancer from 'react-wrap-balancer'; +import { description as homeDescription } from '@/app/(main)/layout.config'; + +// MDX 文章 Header +function MdxHeader(props: { page: MDXPage; tags?: string[] }) { + const { page, tags } = props; + + return ( + <Section className="p-4 lg:p-6"> + <div + className={cn( + 'flex flex-col items-start justify-center gap-4 py-8 md:gap-6', + 'sm:items-center sm:rounded-lg sm:border sm:bg-muted/70 sm:px-8 sm:py-20 sm:shadow-xs sm:dark:bg-muted' + )} + > + <div className="flex flex-col gap-2 sm:text-center md:gap-4"> + <h1 className="max-w-4xl font-bold text-3xl leading-tight tracking-tight sm:text-4xl sm:leading-tight md:text-5xl md:leading-tight"> + <Balancer>{page.data.title}</Balancer> + </h1> + <p className="mx-auto max-w-4xl"> + <Balancer>{page.data.description}</Balancer> + </p> + </div> + <div className="flex flex-wrap gap-2"> + {tags?.map((tag) => ( + <TagCard name={tag} key={tag} className=" border border-border " /> + ))} + </div> + </div> + </Section> + ); +} + +// Payload 文章 Header +function PayloadHeader(props: { post: BlogPost }) { + const { post } = props; + + return ( + <Section className="p-4 lg:p-6"> + <div + className={cn( + 'flex flex-col items-start justify-center gap-4 py-8 md:gap-6', + 'sm:items-center sm:rounded-lg sm:border sm:bg-muted/70 sm:px-8 sm:py-20 sm:shadow-xs sm:dark:bg-muted' + )} + > + <div className="flex flex-col gap-2 sm:text-center md:gap-4"> + <h1 className="max-w-4xl font-bold text-3xl leading-tight tracking-tight sm:text-4xl sm:leading-tight md:text-5xl md:leading-tight"> + <Balancer>{post.title}</Balancer> + </h1> + <p className="mx-auto max-w-4xl"> + <Balancer>{post.description}</Balancer> + </p> + </div> + <div className="flex flex-wrap gap-2"> + {post.tags?.map((tag) => ( + <TagCard name={tag} key={tag} className=" border border-border " /> + ))} + </div> + </div> + </Section> + ); +} + +// MDX 文章内容 +function MdxContent({ page }: { page: MDXPage }) { + const { body: Mdx, toc, tags, lastModified } = page.data; + const lastUpdate = lastModified ? new Date(lastModified) : undefined; + + return ( + <> + <MdxHeader page={page} tags={tags} /> + + <Section className="h-full" sectionClassName="flex flex-1"> + <article className="flex min-h-full flex-col lg:flex-row"> + <div className="flex flex-1 flex-col gap-4"> + <InlineTOC + items={toc} + className="rounded-none border-0 border-border/70 border-b border-dashed dark:border-border" + /> + <div className="prose min-w-0 flex-1 px-4"> + <Mdx + components={{ + ...defaultMdxComponents, + File, + Files, + Folder, + Tabs, + Tab, + }} + /> + </div> + <PostComments + slug={page.slugs[0] ?? ''} + className="[&_form>div]:!rounded-none rounded-none border-0 border-border/70 border-t border-dashed dark:border-border" + /> + </div> + <div className="flex flex-col gap-4 p-4 text-sm lg:sticky lg:top-[4rem] lg:h-[calc(100vh-4rem)] lg:w-[250px] lg:self-start lg:overflow-y-auto lg:border-border/70 lg:border-l lg:border-dashed lg:dark:border-border"> + <div> + <p className="mb-1 text-fd-muted-foreground">Written by</p> + <p className="font-medium">{page.data.author}</p> + </div> + <div> + <p className="mb-1 text-fd-muted-foreground text-sm">Created At</p> + <p className="font-medium"> + {new Date(page.data.date ?? page.file.name).toDateString()} + </p> + </div> + {lastUpdate && ( + <div> + <p className="mb-1 text-fd-muted-foreground text-sm">Updated At</p> + <p className="font-medium">{lastUpdate.toDateString()}</p> + </div> + )} + <Share url={page.url} /> + </div> + </article> + </Section> + <PostJsonLd page={page} /> + </> + ); +} + +// Payload 文章内容 +function PayloadContent({ post }: { post: BlogPost }) { + return ( + <> + <PayloadHeader post={post} /> + + <Section className="h-full" sectionClassName="flex flex-1"> + <article className="flex min-h-full flex-col lg:flex-row"> + <div className="flex flex-1 flex-col gap-4"> + <RichText + content={post.content as Record<string, unknown>} + className="flex-1 px-4" + enableProse={true} + /> + <PostComments + slug={post.slug} + className="[&_form>div]:!rounded-none rounded-none border-0 border-border/70 border-t border-dashed dark:border-border" + /> + </div> + <div className="flex flex-col gap-4 p-4 text-sm lg:sticky lg:top-[4rem] lg:h-[calc(100vh-4rem)] lg:w-[250px] lg:self-start lg:overflow-y-auto lg:border-border/70 lg:border-l lg:border-dashed lg:dark:border-border"> + <div> + <p className="mb-1 text-fd-muted-foreground">Written by</p> + <p className="font-medium">{post.author}</p> + </div> + <div> + <p className="mb-1 text-fd-muted-foreground text-sm">Created At</p> + <p className="font-medium">{post.date.toDateString()}</p> + </div> + <div> + <p className="mb-1 text-fd-muted-foreground text-sm">Updated At</p> + <p className="font-medium">{post.updatedAt.toDateString()}</p> + </div> + <Share url={post.url} /> + </div> + </article> + </Section> + </> + ); +} + +export default async function Page(props: { + params: Promise<{ slug: string }>; +}) { + const params = await props.params; + + // 先尝试获取 MDX 文章 + const mdxPage = getPost([params.slug]); + + if (mdxPage) { + return <MdxContent page={mdxPage} />; + } + + // 再尝试获取 Payload 文章 + const payloadPost = await getPostBySlug(params.slug); + + if (payloadPost) { + return <PayloadContent post={payloadPost} />; + } + + // 都找不到则 404 + notFound(); +} + +export async function generateMetadata(props: { + params: Promise<{ slug: string }>; +}): Promise<Metadata> { + const params = await props.params; + + // 先尝试 MDX 文章 + const mdxPage = getPost([params.slug]); + + if (mdxPage) { + const title = mdxPage.data.title; + const description = mdxPage.data.description ?? homeDescription; + + return createMetadata( + metadataImage.withImage(mdxPage.slugs, { + title, + description, + openGraph: { + url: `/posts/${mdxPage.slugs.join('/')}`, + }, + alternates: { + canonical: mdxPage.url, + }, + }) + ); + } + + // 再尝试 Payload 文章 + const payloadPost = await getPostBySlug(params.slug); + + if (payloadPost) { + return createMetadata({ + title: payloadPost.title, + description: payloadPost.description || homeDescription, + openGraph: { + url: `/posts/${payloadPost.slug}`, + }, + alternates: { + canonical: `/posts/${payloadPost.slug}`, + }, + }); + } + + return {}; +} + +export async function generateStaticParams(): Promise<{ slug: string }[]> { + // MDX 文章的 slugs + const mdxSlugs = getPosts() + .map((page) => page.slugs[0]) + .filter((slug): slug is string => !!slug) + .map((slug) => ({ slug })); + + // Payload 文章的 slugs + const payloadSlugs = await getAllPostSlugs(); + const payloadParams = payloadSlugs.map((slug) => ({ slug })); + + return [...mdxSlugs, ...payloadParams]; +} |
